Skip to content

Escape angle brackets in attachment JSON so pasted attachments survive DOMPurify - #1338

Merged
jeremy merged 9 commits into
fix-data-trix-attributes-safe-for-xmlfrom
security/escape-attachment-json-for-paste
Sep 10, 2026
Merged

Escape angle brackets in attachment JSON so pasted attachments survive DOMPurify#1338
jeremy merged 9 commits into
fix-data-trix-attributes-safe-for-xmlfrom
security/escape-attachment-json-for-paste

Conversation

@jeremy

@jeremy jeremy commented Aug 28, 2026

Copy link
Copy Markdown
Member

Stacked on #1337 (base: fix-data-trix-attributes-safe-for-xml). This diff is only the layer on top; merge #1337 first.

The bug

Copying an attachment out of Trix and pasting it back silently dropped it whenever the attachment JSON contained </style> or another sequence DOMPurify's SAFE_FOR_XML mode treats as a raw-text or comment terminator. Paste runs the clipboard's text/html through HTMLParser under SAFE_FOR_XML, and DOMPurify's attribute rule removes the whole data-trix-attachment attribute on a match, before the forceKeepAttr set by Trix's uponSanitizeAttribute hook is honored. Quoted mail with an embedded <style> block is the common case: in HEY, 67 of 153 real mail bodies lost their embedded content on paste. Dragging the same content was lossless.

What #1337 does, and what this adds

#1337 stashes every data-trix-* value in uponSanitizeAttribute and puts it back in afterSanitizeAttributes once DOMPurify has dropped it. That stops the loss at the sanitizer for every data-trix-* attribute, without changing the HTML Trix emits.

This PR removes the trigger from the value itself, so nothing has to be put back:

  • Emitting side. AttachmentView writes data-trix-attachment and data-trix-attributes with < and > escaped as \u003c / \u003e. Trix's own HTML then never carries a SAFE_FOR_XML trigger, so it survives a paste into any Trix build — including releases without either fix — and into anything else that runs DOMPurify in that mode.
  • Sanitizing side. HTMLSanitizer rewrites those two attributes the same way before DOMPurify runs, so stored, server-rendered and older-Trix HTML with literal brackets is safe by construction: the rule has nothing to match.

The rewrite is lossless: in JSON text, angle brackets only occur inside string literals, where the escapes spell the same characters, so JSON.parse reads back the same value. Only values that already parse as JSON are rewritten; a malformed value is left alone, since HTMLParser ignores it either way.

With the two JSON attributes escaped before DOMPurify runs, #1337's restore hook had nothing left to restore except malformed or non-JSON data-trix-* values that SAFE_FOR_XML deliberately dropped, so this branch removes it (0004e08) and relies on escaping plus forceKeepAttr. A non-JSON data-trix-* value carrying a trigger is now removed, as DOMPurify intends, and a test pins that.

The </html> truncation

Stored-shape attachment JSON with literal brackets exposed a second, older bug on the same path. HTMLSanitizer trims pasted HTML at </html> because Windows browsers can append clipboard bytes after it (778f4d8, 2016), which the parser would otherwise append to the body as text. The trim was a plain string replace, so a </html> inside an attribute value — an attachment whose content is a full HTML document, which is what a quoted mail is — cut the string mid-attribute and dropped the attachment and everything after it. The trim now asks the browser's tokenizer which </html> is the tag: every </html in the string is swapped for a marker start tag and the string parsed once, and the first marker that comes out as an element was tokenized as a tag, while one inside an attribute value, a comment or a raw-text element such as <style> stays text. The extra parse runs only when the string contains </html at all. Dropping the trim outright was checked and rejected: the fragment parser does reparent post-</html> text into the body, so the Word-paste test regresses without it.

What the rebase changed

Proof

  • helpers/strings_test: escapes every bracket, round-trips through JSON.parse (backslashes before brackets, pre-escaped input, surrogate pairs, nesting), idempotent.
  • html_sanitizer_test: attachment and caption JSON containing each of the twelve sequences in DOMPurify's regex survive under SAFE_FOR_XML with no raw brackets; a </style> attachment nested inside another attachment's content survives; malformed JSON is untouched; a non-JSON data-trix-* value carrying --> or </style> is removed under SAFE_FOR_XML; clipboard bytes after </html> are cut while a </html> inside an attribute value, a comment closed with --!>, a <textarea> or a <style> is not; two </html> inside one attribute value, an input-supplied marker element, and </html\u00a0> are each handled as the browser tokenizes them.
  • html_parser_test: the pasted markup parses back to an attachment with the original content and caption, including the nested case, and an attachment whose content is a full <html>…</html> document keeps both the attachment and the paragraph after it.
  • document_view_test: rendered attachment JSON carries no raw brackets and parses back to the original.
  • pasting_test (system): pasting the editor's own value for a <style>-bearing attachment with a </style> caption yields a second, identical attachment; the same for stored-shape markup with literal brackets whose content closes both </style> and </html>.
  • Each half is pinned independently: reverting attachment_view.js alone fails the render tests, reverting html_sanitizer.js alone fails the sanitizer and parser tests.
  • Full suite locally on the stacked branch: 536 tests, 507 passed, 0 failed, 29 skipped.

Copilot AI balanced review requested due to automatic review settings August 28, 2026 08:53
@jeremy
jeremy force-pushed the security/escape-attachment-json-for-paste branch from 02743cc to aafa512 Compare August 28, 2026 08:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Escapes angle brackets in attachment JSON before rendering and sanitization, preventing DOMPurify from dropping attachments during paste.

Changes:

  • Added lossless JSON angle-bracket escaping.
  • Applied escaping during attachment rendering and pre-sanitization.
  • Added comprehensive unit and system regression coverage.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/trix/views/attachment_view.js Escapes emitted attachment JSON attributes.
src/trix/models/html_sanitizer.js Escapes valid legacy JSON before DOMPurify.
src/trix/core/helpers/strings.js Adds the escaping helper.
src/test/unit/html_sanitizer_test.js Covers DOMPurify trigger sequences.
src/test/unit/html_parser_test.js Tests attachment parsing during paste.
src/test/unit/helpers/strings_test.js Tests escaping and round-trip behavior.
src/test/unit/document_view_test.js Verifies safely rendered attributes.
src/test/unit.js Registers the new helper tests.
src/test/test_helpers/fixtures/fixtures.js Updates expected attachment markup.
src/test/test_helpers/editor_helpers.js Adds stored attachment HTML generation.
src/test/system/pasting_test.js Adds end-to-end paste regressions.
action_text-trix/app/assets/javascripts/trix.js Updates the generated browser bundle.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@jeremy
jeremy force-pushed the security/escape-attachment-json-for-paste branch from 2515e30 to affbe0f Compare August 28, 2026 09:12
@jeremy
jeremy changed the base branch from main to fix-data-trix-attributes-safe-for-xml August 28, 2026 09:12
@jeremy
jeremy requested a balanced review from Copilot August 28, 2026 09:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

@jeremy
jeremy force-pushed the security/escape-attachment-json-for-paste branch from affbe0f to 62eab4c Compare August 30, 2026 04:45
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-09T21:23:31.528833Z aaa9497 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 62eab4c646

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/trix/models/html_sanitizer.js Outdated
@jeremy
jeremy force-pushed the security/escape-attachment-json-for-paste branch 2 times, most recently from c1e9df8 to 8dd9a1f Compare August 30, 2026 05:40
Jeremy Daer and others added 2 commits September 8, 2026 23:13
…e DOMPurify

Copying an attachment out of Trix and pasting it back silently dropped it
whenever the attachment JSON contained "</style>" or another sequence
DOMPurify's SAFE_FOR_XML mode treats as a raw-text or comment terminator.
Paste runs the clipboard's text/html through HTMLParser under
SAFE_FOR_XML, and DOMPurify's attribute rule removes the whole
data-trix-attachment attribute on a match, before the forceKeepAttr set by
Trix's uponSanitizeAttribute hook is honored. Quoted mail carrying an
embedded <style> block is the common case. Dragging the same content was
lossless.

Escape "<" and ">" inside the JSON as "\u003c" and "\u003e" at both ends:
AttachmentView emits data-trix-attachment and data-trix-attributes that way,
so Trix's own HTML never carries a trigger sequence, and HTMLSanitizer
rewrites those attributes before DOMPurify sees them, so stored, server-
rendered and older-Trix HTML with literal brackets survives too. In JSON
text angle brackets only occur inside string literals, where the escapes
spell the same characters, so JSON.parse reads back the same value; the
sanitizer only rewrites values that already parse as JSON.
#1337 stashed every data-trix-* attribute DOMPurify's SAFE_FOR_XML pass
dropped and restored it in afterSanitizeAttributes. The escaping added here
makes that unnecessary: sanitizeElement escapes the angle brackets in the
JSON attachment attributes before DOMPurify runs, so SAFE_FOR_XML never
drops them and forceKeepAttr keeps them. The blanket restore's only
remaining effect was re-admitting malformed or non-JSON data-trix-* values
the pass deliberately dropped, so remove it and the module-global stash and
rely on escaping. Non-JSON attachment attributes are unusable on read
anyway.
@rosa

rosa commented Sep 9, 2026

Copy link
Copy Markdown
Member

🤖 Rebased onto the rebased #1337 branch (itself on main@47004013). GitHub now reports MERGEABLE.

The CONFLICTING state was real, not a stale recompute: the base branch had been force-pushed to a rebased copy of its own commits after this branch was built on the older SHAs, so the same two patches existed twice at different SHAs. Replaying this branch's two commits onto the current base clears it.

Review changes:

  • Fixed a comment in sanitizeElement that still described the restore hook this branch removes ("The hooks above put back a data-trix-* attribute…"). It describes the final state now.
  • Trimmed the uponSanitizeAttribute comment.
  • Took an internal measurement out of the "Escape angle brackets…" commit message. Public repo, so it describes the behaviour ("quoted mail carrying an embedded <style> block is the common case") without the sampled numbers. Also dropped "Full suite green." from the other message.

Tests (Node 18.20.8, Playwright Chromium):

  • yarn test → 529 tests, 500 passed, 0 failed, 29 skipped
  • yarn build is idempotent, so the "generated npm changes are not checked-in" step passes
  • action_text-trix bin/rails test:all → 1 run, 1 assertion, 0 failures

Negative controls: removing the read-side escape in sanitizeElement fails 20 tests; removing the write-side escape in AttachmentView#getData fails 3. Both ends are covered.

Fuzzed escapeAngleBracketsInJSON over 200,000 generated values (nested objects and arrays, backslashes, quotes, astral characters, pre-existing \u003c escape text, </style>, -->, ]]>): no angle bracket ever survives, the transform is idempotent, and JSON.parse round-trips to an identical value every time.

Interaction to settle before merging: #1334 also edits uponSanitizeAttribute and will conflict. The escaping here makes #1334's neutralization redundant for anything that parses as JSON, so the resolution should keep this branch's hook. Detail in a comment on #1334.

Worth a maintainer's eye: this changes the shape of the HTML Trix saves — data-trix-attachment and data-trix-attributes now carry \u003c/\u003e in place of literal angle brackets. Anything server-side that JSON.parses the attribute is fine, but anything that string-matches it, or that treats a re-serialized document as changed, will see a difference the first time existing content is opened and saved. The action_text-trix suite in this repo is a single smoke test, so the meaningful check is the downstream Rails Action Text job.

This PR had no independent adversarial review round in this pass — the reviewer pool was saturated. Everything above is my own review plus the negative controls and fuzzing named.


CI after this push: 20 of 21 checks green, including Browser tests (Sauce: Windows Chrome, Firefox and Edge, 476 passed each) and the full Action Text matrix.

The one red check, "Downstream Rails integration tests", is not from this branch. I dispatched a control run of the same workflow on unmodified main (run 34325807733) and that job fails there too, with JSON::GeneratorError: detected duplicate key "latency" inside Rails' system-test network-emulation helper, followed by a net::ERR_INTERNET_DISCONNECTED cascade through the remaining system tests. The job last passed on 2026-09-06 (#1352), so this arrived from upstream rails/rails main since then. It needs its own fix; nothing on this branch can address it.

…ments

Pasted HTML is trimmed at "</html>" because Windows browsers can append
clipboard bytes after it, which the parser would otherwise append to the
body as text. The trim ran as a plain string replace, so a "</html>" inside
an attribute value cut the string mid-attribute and dropped everything from
that element on. Attachment JSON hits this whenever the content is a full
HTML document, as stored and server-rendered attachments carry it with
literal angle brackets: pasting a quoted mail attachment lost the
attachment and everything after it.

Scan for the closing tag as a tag, skipping over quoted attribute values
and comments, so only the real end tag ends the document.
@jeremy

jeremy commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Two follow-ups from a further adversarial pass, pushed as 6b945e8 and b09fc9f.

</html> truncation (6b945e8). HTMLSanitizer trims pasted HTML at </html> as a plain string replace (778f4d8, 2016: Windows browsers append clipboard bytes after the closing tag, and the fragment parser reparents them into the body as text — verified in Chromium, the Word-paste test regresses if the trim is simply dropped). Applied to the raw string, a </html> inside an attribute value cut the string mid-attribute, so a stored-shape attachment whose content is a full HTML document — a quoted mail — lost the attachment and everything after it: attachmentHTML({contentType:"text/html", content:"<html>…</html>"}) + "<p>after</p>" parsed to zero attachments. The trim now scans for the closing tag as a tag, skipping quoted attribute values and comments, so only the real end tag ends the document. Tests: parser-level (attachment plus trailing paragraph survive), sanitizer-level (gunk after the real tag is cut; a </html> inside an attribute or comment is not), and the stored-shape paste round-trip now closes </html> as well as </style>.

Non-JSON data-trix-* under SAFE_FOR_XML (b09fc9f). The hook comment claimed a non-JSON value carrying a trigger is left for DOMPurify to remove; nothing pinned it. Added the test: data-trix-attributes='caption -->' and data-trix-content-type='</style>' are removed while the JSON data-trix-attachment beside them is kept.

PR body updated to match (the restore-hook paragraph was stale since 0004e08). Bundle rebuilt and idempotent; 533 tests, 504 passed, 0 failed, 29 skipped.

@codex review

@jeremy
jeremy requested a balanced review from Copilot September 9, 2026 20:37
Comment thread src/trix/models/html_sanitizer.js Fixed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The closing-tag scanner can still truncate valid pasted content when </html> appears in raw-text elements or similarly named end tags.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

src/trix/models/html_sanitizer.js:168

  • The prefix check also accepts unrelated end tags such as </htmlish>. The browser ignores that unknown end tag, but this function truncates everything after it. Require a tag-name boundary before treating the token as the closing html tag.
    if (/^<\/html/i.test(match[0])) {
  • Files reviewed: 12/12 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread src/trix/models/html_sanitizer.js Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b09fc9f131

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/trix/models/html_sanitizer.js Outdated
Comment thread src/trix/models/html_sanitizer.js Outdated
Comment thread src/trix/models/html_sanitizer.js Outdated
The tag pattern let whitespace and an unquoted attribute value match the
same characters more than one way, so a pasted string such as "<A !=" with
"  !=" repeated took exponential time to reject: 50 seconds for 125
characters. Give every part of a tag a single way to match, and let an
unterminated comment, quoted value or tag run to the end of the string as
the HTML tokenizer does, so a scan never restarts inside one.
A regex that knows about attribute values and comments still isn't the
tokenizer: a "</html>" inside a style or textarea element, or a comment
closed with "--!>", was taken for the closing tag, and each such case would
need its own rule. Ask the browser instead. Swap every "</html" in the
string for a marker start tag and parse once; the first marker that comes
out as an element was tokenized as a tag, so that is where the document
ends, and any marker inside an attribute value, a comment or raw text stays
text. One extra parse, only when the string contains "</html" at all, and
no String.prototype.matchAll for the Safari 12.1 target.
@jeremy

jeremy commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Pushed cedf67e on top of 4f98974, addressing all four threads at the layer they point at: the regex tokenizer is gone. Every </html in the pasted string is swapped for a marker start tag and the string parsed once by the browser; the first marker that comes out as an element was tokenized as a tag and is the cut, while a marker inside an attribute value, a comment (any ending), a <style> or a <textarea> stays text. The extra parse runs only when the string contains </html at all, and there is no matchAll. Suite: 533 tests, 504 passed, 0 failed, 29 skipped; bundle rebuilt and idempotent.

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cedf67e4ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/trix/models/html_sanitizer.js Outdated
Comment thread src/trix/models/html_sanitizer.js Outdated
Comment thread src/trix/models/html_sanitizer.js Outdated
The marker carried its offset in a quoted attribute value, and that quote
changed the tokenizer's state when the marker landed inside a quoted
attribute value: with two "</html>" in one value, the first marker closed
the value and its element, and the second was parsed as a real tag. A
quoted mail chain with two HTML documents in one attachment hit this.
Write the offset unquoted, so the marker carries nothing that changes state
in any context.

Accept only markers whose offset points at a "</html" in the source, so an
element of the same name supplied by the input can't stand in for one, and
bound the tag name by HTML's ASCII whitespace rather than the JavaScript
\s class, which the tokenizer doesn't share.
@jeremy

jeremy commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Pushed 2b079b2 for the three threads on cedf67e: the marker's offset is now an unquoted attribute value (no tokenizer-state characters), only markers whose offset points at a real </html count, and the tag boundary is HTML ASCII whitespace. Each has a test that was red against cedf67e. Suite: 536 tests, 507 passed, 0 failed, 29 skipped; bundle rebuilt and idempotent.

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2b079b2382

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/trix/models/html_sanitizer.js Outdated
An element in the input with the marker's name and an offset pointing at a
"</html" inside an attribute value passed for a marker and cut the paste
there. Give the marker a name chosen per call instead of checking offsets
after the fact: nothing in the input can carry a name it doesn't know, so
every element found is one the scan inserted.
@jeremy

jeremy commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Pushed 2f6c150: the marker's tag name now carries a per-call token, replacing the after-the-fact offset check, so an input-supplied element can't pass for a marker. Suite: 536 tests, 507 passed, 0 failed, 29 skipped; bundle rebuilt and idempotent.

@codex review

@jeremy

jeremy commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

Converged at aaa9497aed, stacked on #1337 (base fix-data-trix-attributes-safe-for-xml). Zero unresolved review threads; the last reviewer activity was Codex at 21:15Z on 2026-09-09, and the final push at 21:18Z drew no new findings.

Gate. CI run 34406221095 on this head is 20 of 21 green: Browser tests on Sauce (Windows 10 Chrome/Firefox/Edge: 506 passed, 0 failed, 30 skipped each; Android Chrome: 507 passed, 0 failed, 29 skipped; 536 tests), the full Action Text matrix (13/13), GitHub Actions audit, and CodeQL with its three Analyze jobs (the earlier inefficient-regex alert is gone with the regex). The one red, Downstream Rails integration tests, is red on trix main itself today: main@47004013's run (2026-09-09 07:49Z) fails the same job with the same error, JSON::GeneratorError: detected duplicate key "latency" raised from selenium-webdriver 4.32.0's network_conditions= under Ruby 3.4.10 in Rails' actiontext system tests (4 errors of 12 runs there, identical here). Not from this branch and not fixable in it. (main's run also hit the known Ferrum boot-timeout flake on the 3.2/7-2-stable cell; that cell is green here.)

Threads. 10 total, 10 fixed, 0 declined: the afterSanitizeAttributes restore hook re-admitting attributes SAFE_FOR_XML rejected, removed; the CodeQL exponential regex, Copilot's RAWTEXT/RCDATA tokenizing, Codex's quadratic scan, matchAll on Safari 12.1 and --!> comment endings, all answered at once by dropping the regex tokenizer for a single browser parse with marker tags (cedf67e4); the marker's quoted offset flipping tokenizer state, the \s boundary and pre-existing marker elements (2b079b23); injected markers made indistinguishable from source elements by a per-call token in the tag name (2f6c1506), with its forged-marker test corrected to actually fail against the previous commit (aaa9497a).

Rounds. Six reviewer rounds since opening: Copilot twice on 2026-08-28, Codex on 08-30, then on 09-09 CodeQL + Copilot + Codex (20:37 to 20:46Z), Codex at 21:05Z, Codex at 21:15Z, each re-summoned on the push it reviewed.

Merge order stands: #1337, then this, then optionally #1353 (stacked on this branch); #1334 closes, its hook obviated by the escaping here. Nothing merged.

@jeremy
jeremy merged commit 7c14443 into main Sep 10, 2026
20 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants